iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
AI Engineering

從 LLM 到 Harness: 打造隱私與可信任的繁中進階 OCR Agent系列 第 16 篇

Day 16 - 關鍵欄位語意驗證:金額、日期、簽約對象一致性檢查

  • 分享至 

  • xImage
  •  

Day 14 那筆 M-01 我一直忘不掉。

「民國一百一十一年七月二十五日/3,065,000股」被讀成「民國一十一年十二月十五日/3,065,000股」。股數一個字都沒錯,日期錯了一百年又換了月份。如果只拿 CER 看,這一格錯了幾個字而已;如果只看數字有沒有出現,它拿滿分。

但任何一個看得懂年報的人,看到「民國十一年發行限制員工權利新股」都會笑出來。民國十一年是 1922 年,台積電還要再等六十幾年才成立。

那個「笑出來」的反應,就是今天想寫進程式的東西。

一個總分交代不了三種錯

很多 OCR 系統會給每一頁一個信心分數,0.93、0.87 之類。我以前也覺得這樣就夠了,分數低的送人工看。

後來發現這個分數回答不了一個很基本的問題:到底是哪裡可疑?

金額錯、日期錯、公司名稱錯,是三種不一樣的錯。金額要看符號、千分位、單位、括號負數。日期要看日曆上存不存在,同一件事在不同頁出現時是不是同一天。公司名稱最麻煩,要分得出「臺灣」跟「台灣」是同一家,又不能把「永光化學」跟「永光化學工業」合併成一家。

這三種規則沒辦法加權平均成一個數字。所以我的設計是:每一類欄位各自檢查,各自回報疑點,疑點要指得出在哪一頁、原文是什麼、為什麼可疑。格式沿用 Day 11 訂的 VerifierIssue:location、original_text、concern、confidence。

原文一個字都不能改

在寫規則之前,我先定了一條死規矩:驗證器只能標記,不能修改。

這條規矩來自 Day 7。當時處理「台」跟「臺」這類異體字,我學到的是:正規化只能拿來比較,原始輸出必須原封不動留著。一旦你把「臺」寫回成「台」,之後就沒有人知道原文到底長什麼樣了。

放到金額上更嚴重。假設 OCR 讀出 NT$1,23元,千分位明顯不對。驗證器很「聰明」地猜它應該是 1,230 或 123,然後幫你改掉。改對了沒人感謝你,改錯了就是一筆看起來完全正常的錯誤數字,躺在資料庫裡等著被引用。

修正一個看起來合理的數字,比留一面紅旗危險多了。

所以資料結構長這樣:

@dataclass(frozen=True)
class FieldValue:
    """一個從文件擷取出的欄位值;value 必須保持原始 OCR/文件文字。"""
    location: str
    page_ref: str
    key: str
    value: str

frozen=True 是故意的,讓程式在語言層面就不能改 value。比較用的正規化值另外算,從來不寫回去。

三類規則

程式在 D:\iron-people\harness\semantic_field_validator.py,純本地,不連網、不呼叫模型。

金額

分四件事檢查:貨幣符號(NT$、新台幣、$)、單位(元、千元、萬元、百萬元、千萬元、億元)、括號負數、千分位。

def validate_amount(field: FieldValue) -> list[VerifierIssue]:
    issues = []
    text = field.value.strip()
    currency = next((c for c in _CURRENCIES if text.startswith(c)), None)
    if currency is None:
        issues.append(_issue(field, "金額缺少可辨識的貨幣符號(NT$、新台幣或 $)。"))
        remainder = text
    else:
        remainder = text[len(currency):]

    unit = next((u for u in _UNITS if remainder.endswith(u)), None)
    if unit is None:
        issues.append(_issue(field, "金額缺少可辨識的單位(例如元、千元或萬元)。"))
        numeric = remainder
    else:
        numeric = remainder[: -len(unit)]

    if "(" in numeric or ")" in numeric:
        if numeric.startswith("(") and numeric.endswith(")") and numeric.count("(") == numeric.count(")") == 1:
            numeric = numeric[1:-1]
        else:
            issues.append(_issue(field, "括號負數格式不完整;負數括號必須完整包住數值。"))
            numeric = numeric.replace("(", "").replace(")", "")

    if numeric.startswith("-"):
        numeric = numeric[1:]
    if not re.fullmatch(r"\d+|\d{1,3}(?:,\d{3})+", numeric):
        issues.append(_issue(field, "數值或千分位格式不合法;逗號後每組必須剛好三位數。"))
    return issues

NT$(1,234)元 會通過,括號負數在財報裡很常見。NT$1,23元 會被標記千分位有問題,但程式不會去猜正確值是什麼。

單位的比對順序有一個小陷阱:_UNITS 要把長的放前面(千萬元、百萬元在千元前面,元放最後),不然「千萬元」會先被「萬元」吃掉,剩下一個「千」黏在數字上。

日期

支援 YYYY-MM-DD、YYYY/MM/DD、YYYY年M月D日。先確認日曆上存在(2 月 30 日會被擋),再把同一個欄位鍵的日期分組,跨頁出現但值不同就標記。

for key, entries in by_key.items():
    pages = {field.page_ref for field, _ in entries}
    values = {parsed_date for _, parsed_date in entries}
    if len(pages) > 1 and len(values) > 1:
        expected = entries[0][1].isoformat()
        for field, parsed_date in entries[1:]:
            if parsed_date != entries[0][1]:
                issues.append(_issue(field, f"同鍵日期「{key}」跨頁不一致;另一頁為 {expected}。"))

跨頁一致性為什麼重要?年報裡同一個日期可能在好幾個章節重複出現,例如股東會日期、董事會決議日期。OCR 在其中一處讀錯,另外兩處讀對,這就是最便宜的抓錯機會。不需要答案卷,文件自己跟自己對質就好。

簽約對象

這是三類裡我猶豫最久的。

直接比字串的話,「臺灣永光化學股份有限公司」跟「台灣永光化學股份有限公司」會被判成兩家公司。我第一個念頭是上模糊比對,相似度夠高就算同一家。但在紙上推一下就知道會出事:「台灣永光化學股份有限公司」跟「台灣永光化學工業股份有限公司」只差兩個字,相似度一樣很高,會被合併。這兩家在真實世界裡可能是不同的法人。

模糊比對在這裡是錯的工具。公司名稱的差異不是雜訊,差兩個字可能就是另一家公司。

最後的版本很保守:只折疊幾個明確等價的異體字,其他一律照原文比。

# 折疊表節錄:實際檔案裡還有另外四組異體字對應,這裡只列最常遇到的一組
_COUNTERPARTY_FOLD = str.maketrans({"臺": "台"})


def counterparty_comparison_key(value: str) -> str:
    """只供相等比對的保守比較鍵;原始公司名稱不會被改寫或合併。"""
    return unicodedata.normalize("NFC", value).translate(_COUNTERPARTY_FOLD)

折疊表在原始檔案裡總共五組,除了臺/台,其他四組是 OCR 偶爾會吐出來的少見異體寫法,折到常用寫法。這張表我刻意維持很短。每多一組對應,就多一個「兩個不同的名字被當成同一個」的機會。原文照樣保留,只有比較的時候會折疊。

直接執行 python harness/semantic_field_validator.py 會跑一組合成 fixture:合法金額、錯誤千分位、括號負數、同鍵跨頁日期不一致、台/臺等價、相近公司名不合併。全部 assertion 通過才印出 PASS。我重跑確認過,印出的是 PASS。

拿真實資料一跑,被自己的規則打臉

合成 fixture 全過,我就想拿第 61 頁的真實欄位試試看。三筆裁罰,Day 15 已經抽出來了:

公文日期 公文字號 罰鍰原文
民國 114 年 4 月 17 日 竹環字第1140012798號 新台幣40萬元
民國 114 年 5 月 8 日 竹環字第1140014905號 新台幣45萬元
民國 114 年 9 月 4 日 竹環字第1140028822號 新台幣45萬元

金額那一欄沒問題,三筆 新台幣40萬元、新台幣45萬元 丟進 validate_amount,都回傳空清單,通過。

日期那一欄直接出事。原文寫的是「114年4月17日」,我丟進 validate_dates:

日期格式或日曆日期不合法;支援 YYYY-MM-DD、YYYY/MM/DD、YYYY年M月D日。

我的日期規則要求四位數的西元年。台灣的年報、公文,幾乎全部用民國紀年。

這真的有點丟臉。我花了一整篇文章在講繁中的特殊性,結果寫驗證器的時候,日期格式照抄了西元的習慣。合成 fixture 全部用西元日期,所以測試全過,什麼都沒抓到。fixture 是我自己寫的,它只會測到我想得到的情況。

同一次還發現另一個問題。我順手把 Day 14 那筆 3,065,000股 丟進金額檢查,冒出三個疑點:缺貨幣符號、缺單位、千分位格式不合法。股數被當成金額驗了。這不是規則寫錯,是我沒有先分欄位型態。股數、金額、百分比,各自要走各自的規則。

Tip:寫完驗證規則,第一件事是拿一筆「確定是對的」真實資料丟進去。如果它被標記了,錯的是規則,不是資料。合成 fixture 只能證明規則照你的想法運作,證明不了你的想法是對的。

補上民國紀年,順便抓到 M-01

所以我在 scratch 目錄裡寫了一個擴充原型,還沒併進 harness/。它做兩件事:把民國紀年跟國字數字轉成西元日期,再加一條跨欄位規則。

import re
from datetime import date

_DIGIT = {"〇": 0, "零": 0, "一": 1, "二": 2, "三": 3, "四": 4,
          "五": 5, "六": 6, "七": 7, "八": 8, "九": 9}
_UNIT = {"十": 10, "百": 100}


def zh_num(s: str) -> int:
    """「一百一十一」「二十五」「十二」這類國字數字轉整數,只處理到百。"""
    if s.isdigit():
        return int(s)
    total, cur = 0, 0
    for ch in s:
        if ch in _DIGIT:
            cur = _DIGIT[ch]
        elif ch in _UNIT:
            total += (cur or 1) * _UNIT[ch]
            cur = 0
        else:
            raise ValueError(f"看不懂的字:{ch}")
    return total + cur


_ROC_RE = re.compile(r"(?:民國)?\s*([〇零一二三四五六七八九十百\d]+)\s*年\s*"
                     r"([〇零一二三四五六七八九十\d]+)\s*月\s*"
                     r"([〇零一二三四五六七八九十\d]+)\s*日")


def parse_roc_date(text: str):
    m = _ROC_RE.search(text)
    if not m:
        return None
    y, mo, d = (zh_num(g) for g in m.groups())
    try:
        return date(y + 1911, mo, d)
    except ValueError:
        return None


def check_issue_after_effective(effective_text, issue_text, report_year=2025, max_age=15):
    """限制員工權利新股:發行日不可早於申報生效日,兩者都要落在合理年份內。"""
    flags = []
    eff, iss = parse_roc_date(effective_text), parse_roc_date(issue_text)
    for name, raw, d in (("申報生效日", effective_text, eff), ("發行日", issue_text, iss)):
        if d is None:
            flags.append(f"{name}「{raw}」解析不出日期")
        elif not (report_year - max_age <= d.year <= report_year):
            flags.append(f"{name}「{raw}」換算成 {d.isoformat()},離年報年度太遠")
    if eff and iss and iss < eff:
        flags.append(f"發行日 {iss.isoformat()} 早於申報生效日 {eff.isoformat()}")
    return flags

zh_num 處理「一百一十一」的方式是:遇到數字先記著,遇到「十」「百」就乘上去累加。「十二」前面沒有數字,cur or 1 讓它當成一十。只處理到百,因為民國紀年目前用不到千。

然後拿 Day 14 第 44 頁左半的真實資料去跑,一組用 ground truth,一組用模型的原始輸出:

GT : []
OCR: ['申報生效日「民國一十一年十二月十五日」換算成 1922-12-15,離年報年度太遠',
      '發行日「民國一十一年三月一日」換算成 1922-03-01,離年報年度太遠',
      '發行日 1922-03-01 早於申報生效日 1922-12-15']

ground truth 一個疑點都沒有:申報生效 2022-07-25,發行 2023-03-01,順序正確。OCR 版本冒出三個。

我看到第三行的時候很開心。它完全沒用到答案卷。它只知道兩件事:發行一定在申報生效之後,而且年報提到的事不會發生在一百年前。光靠這兩條常識,就把 M-01 跟 M-02 抓出來了。

max_age=15 是我隨手定的,意思是年報裡提到的日期,大多數落在報告年度往前 15 年內。這個數字沒有根據,一定會有例外,例如公司沿革會寫到成立的那一年。所以它應該只產生 FLAG,永遠不能拿來擋資料。

簽約對象的別名,還欠一張表

counterparty_comparison_key 處理了台/臺,但年報裡有一個更常見的情況它處理不了:簡稱。

台積電的年報裡,公司自稱「台積公司」,Day 14、15 引用的原文裡就出現好幾次。正式法人名稱是「台灣積體電路製造股份有限公司」。對比較鍵來說,這是兩個完全不同的字串。

我不想把這個對應寫死在程式裡。這跟昨天章節表的道理一樣:「台積公司」指的是誰,是這份文件自己定義的,換一家公司的年報,簡稱就不一樣。應該從文件本身建一張別名表,再讓比較鍵去查。

這張表今天沒做。我連年報裡是在哪一頁、用什麼句型定義這個簡稱,都還沒去確認。

公文字號那一欄倒是有一個很便宜的檢查:同一段落裡三筆裁罰的機關簡稱都是「竹環」,數字前三碼都是 114,跟公文日期的民國 114 年一致。字號前三碼等於發文年度,這是台灣公文字號的常見慣例,但我沒有找到正式的規範文件確認每個機關都這樣編,所以只能當軟性規則用。

規則總表

把今天的東西整理成一張表。狀態欄很重要,它說明哪些是能跑的程式,哪些還只是想法。

欄位型態 規則 需不需要答案卷 狀態
金額 貨幣符號、單位、括號負數、千分位 不需要 已實作於 harness,合成 fixture 通過,第 61 頁三筆真實金額通過
日期(西元) 格式、日曆存在、同鍵跨頁一致 不需要 已實作於 harness,只有合成 fixture 驗證
日期(民國、國字數字) 轉西元、年份合理範圍 不需要 scratch 原型,對 M-01、M-02 能正確標出
日期前後順序 發行日不早於申報生效日 不需要 scratch 原型,同上
簽約對象 台/臺等保守折疊,相近名稱不合併 不需要 已實作於 harness,只有合成 fixture 驗證
簽約對象別名 簡稱對全名,從文件本身建表 不需要 未實作
股數、百分比 各自獨立的格式規則 不需要 未實作;目前股數會被誤送進金額規則
公文字號 字號前三碼與公文年度一致 不需要 想法階段,慣例未查證

「需不需要答案卷」那一欄每一格都是「不需要」,這是故意的。這一層驗證的價值,就在於上線時沒有 ground truth 也能跑。

但這也是它的天花板。

這些規則全部在檢查文件內部是否自洽:日期先後對不對、同一件事前後說法一不一致、格式合不合理。假如 OCR 把 3,065,000 股讀成 3,065,800 股,格式完全合法,前後也沒有別處可以對質,這一層就放行了。

要抓這種錯,需要一把文件外面的尺。財報數字剛好有一把現成的:公司申報給 MOPS 的 XBRL,每一個科目、每一個期間、每一個單位都是結構化的。明天來看它能補上哪幾格。


上一篇
Day 15 - 定義條款與交叉引用解析:年報附註與主表之間的字義怎麼對齊
系列文
從 LLM 到 Harness: 打造隱私與可信任的繁中進階 OCR Agent 共 16 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言